Skip to main content

media_pp\elements\source\capture\windows/
dxgi_capture_source.rs

1use std::{
2    ffi::c_void,
3    sync::Arc,
4    time::{Duration, Instant},
5};
6
7use crate::pp_log::{PpLog, pp_error, pp_info};
8use ffmpeg_next as ffmpeg;
9use thiserror::Error as ThisError;
10use windows::{
11    Win32::{
12        Foundation::{HMODULE, POINT, RECT},
13        Graphics::{
14            Direct3D::D3D_DRIVER_TYPE_UNKNOWN,
15            Direct3D11::{
16                D3D11_BIND_FLAG, D3D11_BIND_SHADER_RESOURCE, D3D11_BOX, D3D11_CPU_ACCESS_READ,
17                D3D11_CREATE_DEVICE_FLAG, D3D11_MAP_READ, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC,
18                D3D11_USAGE_DEFAULT, D3D11_USAGE_STAGING, D3D11CreateDevice, ID3D11Device,
19                ID3D11DeviceContext, ID3D11Resource, ID3D11Texture2D,
20            },
21            Dxgi::{
22                Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC},
23                CreateDXGIFactory1, DXGI_ERROR_ACCESS_LOST, DXGI_ERROR_WAIT_TIMEOUT,
24                DXGI_OUTDUPL_FRAME_INFO, DXGI_OUTDUPL_POINTER_SHAPE_INFO,
25                DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR,
26                DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR,
27                DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME, IDXGIAdapter1, IDXGIDevice,
28                IDXGIFactory1, IDXGIOutput1, IDXGIOutputDuplication, IDXGIResource,
29            },
30        },
31    },
32    core::Interface,
33};
34
35use crate::{
36    buffer::MediaBuffer,
37    bus::{Bus, BusEvent},
38    control::{ControlReceiver, drain_control},
39    element::{Element, ElementType, Source, SourceElement, element_pp_log},
40    elements::filter::decoder::d3d11va_decoder::wrap_d3d11_texture,
41    error::Result,
42    pad::SrcPad,
43    pool::UnboundObjectPool,
44    schedule::PeriodicSchedule,
45};
46
47/// How often [`DxgiCaptureSource::run`]'s poll loop re-checks
48/// `drain_control`/whether it's time to emit, even mid-wait for the next
49/// real desktop change — bounds `Stop` latency at very low configured
50/// [`DxgiCaptureOptions::fps`] values, where "wait until the next tick" on
51/// its own could otherwise be a long, unresponsive block. Same idea as
52/// [`crate::queue::Queue`]'s own `STOP_POLL_INTERVAL`.
53const POLL_GRANULARITY: Duration = Duration::from_millis(100);
54
55/// Errors specific to `DxgiCaptureSource`. Converts into the crate-wide
56/// `Error` via `?` (see [`crate::error::Error`]).
57#[derive(Debug, ThisError)]
58pub enum DxgiCaptureSourceError {
59    #[error("windows error: {0}")]
60    Windows(#[from] windows::core::Error),
61
62    #[error("no DXGI output at index {0} (across every adapter)")]
63    NoSuchOutput(u32),
64
65    /// `DXGI_ERROR_ACCESS_LOST` specifically, broken out of the generic
66    /// [`DxgiCaptureSourceError::Windows`] variant because it's the single
67    /// most common *recoverable* failure mode for desktop duplication —
68    /// a lock screen, a UAC prompt, a display mode change, or a
69    /// fullscreen-exclusive app/overlay stealing the duplication lock all
70    /// surface this way. Same "fail fast, caller rebuilds a fresh one"
71    /// contract [`crate::elements::RtspSource`] already documents: this
72    /// element doesn't retry internally, callers that want to survive a
73    /// lock-screen cycle watch for this specific error and call
74    /// [`DxgiCaptureSource::open`] again.
75    #[error("DXGI_ERROR_ACCESS_LOST — desktop duplication needs to be reopened")]
76    AccessLost,
77
78    #[error("DxgiCaptureSource doesn't support seeking a live capture")]
79    SeekUnsupported,
80
81    #[error("CaptureArea::Region {0:?} doesn't overlap any display output")]
82    RegionOutsideDesktop(CaptureRect),
83
84    /// See [`CaptureArea::Region`]'s own docs on why this is a hard
85    /// failure rather than an automatic CPU-bridged fallback.
86    #[error(
87        "CaptureArea::Region spans outputs on more than one GPU adapter — \
88         zero-copy compositing across adapters isn't supported"
89    )]
90    RegionSpansMultipleAdapters,
91
92    #[error("include_cursor isn't supported when CaptureArea::Region spans more than one output")]
93    CursorUnsupportedForRegion,
94}
95
96/// How [`DxgiCaptureSource::open`] captures each frame — see
97/// [`DxgiCaptureOptions::capture_mode`].
98#[derive(Debug, Clone)]
99pub enum CaptureMode {
100    /// The original behavior: `AcquireNextFrame`'s resource is copied into
101    /// a CPU-readable staging texture, `Map`ped, and copied row-by-row
102    /// into a plain `Pixel::BGRA` CPU frame. No external device required —
103    /// `open` creates its own, internal to this element, from the chosen
104    /// output's own adapter.
105    ///
106    /// `include_cursor` composites the mouse cursor onto every emitted
107    /// frame. Off by default: the base capture path (desktop pixels only)
108    /// needs no extra work, and most consumers (recording, streaming a
109    /// presentation) don't want the cursor baked in at all. Only exists on
110    /// this variant — cursor compositing is CPU-side pixel blending
111    /// (`composite_cursor`), which has nothing to run against under
112    /// [`CaptureMode::Gpu`], where the captured image never touches the
113    /// CPU at all; putting the field here instead of as a separate
114    /// `DxgiCaptureOptions` flag makes that combination unrepresentable
115    /// rather than a runtime error to guard against. Also unsupported
116    /// (a hard `open`-time error, see
117    /// [`DxgiCaptureSourceError::CursorUnsupportedForRegion`]) when
118    /// [`DxgiCaptureOptions::area`] is a [`CaptureArea::Region`] spanning
119    /// more than one output — see that variant's own docs on why.
120    Cpu { include_cursor: bool },
121    /// Captures straight to a GPU-resident frame tagged `Pixel::D3D11`
122    /// (BGRA — desktop content has no reason to go through YUV) — no
123    /// `Map`, no CPU pixel copy at all, just GPU-side `CopyResource`/
124    /// `CopySubresourceRegion` calls (each contributing output's
125    /// duplication resource -> this element's own per-output "latest
126    /// capture" texture, then those -> a fresh per-emission composite
127    /// texture every tick, so an in-flight pushed frame's content can't
128    /// change under whatever's still reading it — same reasoning
129    /// [`crate::elements::D3d11Upload`] documents for building a fresh
130    /// texture per call rather than reusing one).
131    ///
132    /// Unlike [`CaptureMode::Cpu`], this variant carries no device of its
133    /// own to inject: `open` always builds the device itself, from
134    /// whichever adapter [`DxgiCaptureOptions::area`] actually selects
135    /// (the *only* place that resolves "which adapter" — see
136    /// `resolve_area`), and hands it back as `open`'s own return value
137    /// for the caller to reuse. That's the one `ID3D11Device` every other
138    /// D3D11 element sharing this capture's output should be built from
139    /// (e.g. `render_common::D3d11GpuContext::new(Some(device))`) — for
140    /// `open`'s own zero-copy path to mean anything, see
141    /// [`crate::elements::D3d11Renderer`]'s own docs on why. Taking a
142    /// caller-supplied device here instead would only reopen the exact
143    /// adapter-mismatch problem this design avoids: two independently
144    /// resolved "which adapter" answers that would need to be checked
145    /// against each other instead of structurally being the same one.
146    ///
147    /// No cursor option — see [`CaptureMode::Cpu`]'s own docs on why.
148    Gpu,
149}
150
151/// A capture region in absolute virtual-desktop pixel coordinates — the
152/// same origin/units Win32 itself uses for multi-monitor layout
153/// (`GetSystemMetrics(SM_XVIRTUALSCREEN)`, `MONITORINFOEX::rcMonitor`,
154/// `DXGI_OUTPUT_DESC::DesktopCoordinates`), not local to any one monitor.
155/// See [`CaptureArea::Region`].
156#[derive(Debug, Clone, Copy)]
157pub struct CaptureRect {
158    pub x: i32,
159    pub y: i32,
160    pub width: u32,
161    pub height: u32,
162}
163
164/// Which portion of the desktop [`DxgiCaptureSource::open`] duplicates —
165/// see [`DxgiCaptureOptions::area`].
166#[derive(Debug, Clone, Copy)]
167pub enum CaptureArea {
168    /// The `output_index`'th output's entire desktop — a flat index
169    /// across every adapter's every output, in enumeration order
170    /// (adapter 0's outputs, then adapter 1's, ...) — "monitor 0",
171    /// "monitor 1", regardless of which GPU each is attached to. `0` is
172    /// whatever Windows considers the first output of the first adapter,
173    /// not necessarily the primary monitor.
174    ///
175    /// The simple case: exactly one `IDXGIOutputDuplication`, no
176    /// cropping, no compositing.
177    Output { output_index: u32 },
178    /// An arbitrary rectangle in absolute virtual-desktop coordinates —
179    /// for callers that only know "this screen area" (e.g. a
180    /// user-dragged region-selection UI), not which monitor index owns
181    /// it. May overlap more than one output: `open` resolves every
182    /// output the rectangle intersects and opens one
183    /// `IDXGIOutputDuplication` per contributing output, then stitches
184    /// each output's contribution into one composite
185    /// `rect.width x rect.height` image every capture tick —
186    /// `CopySubresourceRegion` under [`CaptureMode::Gpu`], a plain
187    /// per-row memory copy under [`CaptureMode::Cpu`] — placing each
188    /// piece at its correct offset, no scaling or blending.
189    ///
190    /// **Every intersected output must share the same adapter.** Desktop
191    /// Duplication resources can't be copied directly across
192    /// `ID3D11Device`s from different adapters without a CPU round trip
193    /// — `open` checks every intersected output's adapter *before*
194    /// opening any duplication, so a rejected region never partially
195    /// opens anything, and fails outright
196    /// ([`DxgiCaptureSourceError::RegionSpansMultipleAdapters`]) rather
197    /// than silently falling back to a CPU bridge for the mismatched
198    /// output — same "hard, loud failure, never a silent auto-copy"
199    /// reasoning as `D3d12Renderer`'s own device-mismatch guard.
200    ///
201    /// `include_cursor` (see [`CaptureMode::Cpu`]) is only valid when the
202    /// region resolves to a single output —
203    /// [`DxgiCaptureSourceError::CursorUnsupportedForRegion`] otherwise.
204    /// The cursor can legitimately straddle a monitor boundary inside a
205    /// stitched composite; handling that correctly isn't done, simplest
206    /// to reject rather than silently draw it wrong.
207    Region(CaptureRect),
208}
209
210/// Construction-time options for [`DxgiCaptureSource::open`].
211#[derive(Debug, Clone)]
212pub struct DxgiCaptureOptions {
213    /// Which output(s) to capture from — see [`CaptureArea`].
214    pub area: CaptureArea,
215    /// The constant rate frames are emitted at — see [`DxgiCaptureSource`]'s
216    /// own docs on why this is a fixed output rate (like
217    /// [`crate::elements::TestVideoSource::new`]'s `framerate`), not a cap
218    /// on an otherwise irregular one. `30` by default, matching
219    /// `TestVideoSource`'s own default.
220    pub fps: u32,
221    /// CPU (the original behavior) or GPU (zero-copy) capture — see
222    /// [`CaptureMode`]. `CaptureMode::Cpu { include_cursor: false }` by
223    /// default, so existing callers building `DxgiCaptureOptions { ..
224    /// ..Default::default() }` keep today's behavior unchanged.
225    pub capture_mode: CaptureMode,
226}
227
228impl Default for DxgiCaptureOptions {
229    fn default() -> Self {
230        Self {
231            area: CaptureArea::Output { output_index: 0 },
232            fps: 30,
233            capture_mode: CaptureMode::Cpu {
234                include_cursor: false,
235            },
236        }
237    }
238}
239
240/// One cached mouse cursor shape — refreshed only when
241/// `DXGI_OUTDUPL_FRAME_INFO::PointerShapeBufferSize` says it changed
242/// (the shape rarely changes frame-to-frame; re-fetching it on every
243/// frame would be wasted work).
244struct CursorShape {
245    kind: u32,
246    width: u32,
247    height: u32,
248    pitch: u32,
249    data: Vec<u8>,
250}
251
252/// One contributing output's own duplication plus this element's copy of
253/// its capture, and where that output's portion belongs in the final
254/// composite image. Exactly one of these exists under
255/// [`CaptureArea::Output`]; [`CaptureArea::Region`] has one per output it
256/// overlaps.
257struct CaptureUnit {
258    duplication: IDXGIOutputDuplication,
259    /// This output's own full-resolution capture — CPU-readable
260    /// (`D3D11_USAGE_STAGING`) under [`CaptureMode::Cpu`], GPU-only
261    /// (`D3D11_USAGE_DEFAULT`, no bind flags) under [`CaptureMode::Gpu`]
262    /// — only the fresh composite texture
263    /// [`DxgiCaptureSource::emit_frame_gpu`] builds needs to be
264    /// shader-bindable, not this one. Sized to this output's own
265    /// resolution, not the final composite's — see `source_box` for the
266    /// (possibly smaller) piece of it actually used.
267    staging_texture: ID3D11Texture2D,
268    /// The sub-rectangle of `staging_texture`, in this output's own
269    /// local pixel coordinates, that actually falls inside the
270    /// requested capture area. The whole texture under
271    /// [`CaptureArea::Output`]; a crop under [`CaptureArea::Region`] —
272    /// every pixel outside this box was requested by nobody, no reason
273    /// to copy it into the composite.
274    source_box: D3D11_BOX,
275    /// Where `source_box`'s pixels land in the final composite image.
276    dest_x: u32,
277    dest_y: u32,
278    /// Whether this unit has captured at least one real image yet — the
279    /// element as a whole is ready to emit only once every unit's own
280    /// flag is `true` (see [`DxgiCaptureSource::all_captured`]), so a
281    /// freshly opened multi-output region never emits with part of the
282    /// composite still blank.
283    has_captured: bool,
284}
285
286/// Captures the desktop via Windows' DXGI Desktop Duplication API
287/// (`IDXGIOutputDuplication`) — GStreamer's `d3d11screencapturesrc`
288/// equivalent. One src pad, pushing `Pixel::BGRA` frames (no internal
289/// color conversion — same division of labor as every other source in
290/// this crate: chain a [`crate::elements::Scaler`] downstream if
291/// something needs YUV420P, e.g. [`crate::elements::D3d12Renderer`]'s
292/// CPU-upload path or [`crate::elements::SwEncoder`]).
293///
294/// Emits at a **constant** rate — [`DxgiCaptureOptions::fps`] — not one
295/// push per real desktop change. An earlier version of this pushed
296/// variable-rate (VFR): a real wall-clock pts per actual change, nothing
297/// in between. That turned out to cause real problems, both for muxing
298/// (most consumers assume something closer to a steady rate) and,
299/// concretely, for live rendering: `D3d12Renderer` presents on a
300/// vsync-locked swap chain (`Present(1, ..)`) that only ever shows the
301/// *latest* submitted frame each tick, silently dropping anything else
302/// queued behind it — submission timing straight off an irregular VFR
303/// source has no relationship to that vsync grid, so real changes would
304/// unpredictably race into the same tick (one silently discarded) or
305/// land in a gap (stale frame held an extra tick), which is visible
306/// judder even though the *average* rate was exactly right.
307///
308/// So instead: this element always keeps the most recently captured
309/// desktop image on hand, and [`SourceElement::run`]'s own loop emits it
310/// — the same one again if nothing changed since the last tick — at a
311/// steady `1 / fps` cadence, entirely on the one thread `run()` already
312/// has (no extra threads spawned; see this crate's own "elements never
313/// spawn their own threads" rule). Same shape as
314/// [`crate::elements::TestVideoSource`]: [`DxgiCaptureSource::time_base`]
315/// is `1 / fps` and `pts` is a plain incrementing tick counter, one per
316/// *emitted* frame, not per real capture.
317///
318/// Confirmed (`examples/render/screen_capture`, with and without a
319/// downstream [`crate::elements::Pacer`]) that this constant-rate,
320/// drift-free schedule is what actually mattered — not whether a
321/// separate `Pacer` stage exists. The VFR version needed one to paper
322/// over its own irregular submission timing; once emission here is
323/// steady and drift-free, `Scaler`'s modest, fairly consistent per-frame
324/// conversion cost isn't enough on its own to reintroduce the same vsync
325/// misalignment, so a straight `DxgiCaptureSource -> Scaler -> D3d12Renderer`
326/// chain stays smooth with no `Pacer` at all. `Pacer` remains genuinely
327/// useful for other reasons (multi-stream sync against a shared `Clock`,
328/// or a stage with real per-frame variance like `SwEncoder`), just not
329/// load-bearing here purely for vsync alignment the way it first
330/// appeared to be.
331///
332/// Deliberately does **not** retry internally on `DXGI_ERROR_ACCESS_LOST`
333/// (lock screen, UAC prompt, display mode change, ...) — same "fail fast,
334/// caller rebuilds" contract as [`crate::elements::RtspSource`]; watch for
335/// [`DxgiCaptureSourceError::AccessLost`] and call
336/// [`DxgiCaptureSource::open`] again.
337///
338/// Runs until `Stop` — never reaches `Eos` on its own, same as
339/// `TestVideoSource` (there's no natural end to a live desktop capture).
340///
341/// May capture from more than one output at once — see
342/// [`CaptureArea::Region`] — in which case every field below that used
343/// to describe "the" duplication instead describes one `CaptureUnit`
344/// per contributing output.
345pub struct DxgiCaptureSource {
346    pp_log: PpLog,
347    name: Arc<str>,
348    /// Only used by [`CaptureMode::Gpu`]'s [`DxgiCaptureSource::emit_frame`]
349    /// path, to build each tick's fresh per-emission composite texture —
350    /// unused after construction in [`CaptureMode::Cpu`], but harmless to
351    /// hold either way (one extra COM reference, same device already
352    /// owns).
353    device: ID3D11Device,
354    context: ID3D11DeviceContext,
355    units: Vec<CaptureUnit>,
356    /// The final composite image's dimensions — `rect.width`/`rect.height`
357    /// under [`CaptureArea::Region`], the single output's own resolution
358    /// under [`CaptureArea::Output`].
359    width: u32,
360    height: u32,
361    gpu_mode: bool,
362    include_cursor: bool,
363    cursor_shape: Option<CursorShape>,
364    /// The cursor's last known position/visibility — *not*
365    /// `DXGI_OUTDUPL_FRAME_INFO::PointerPosition` read fresh every call.
366    /// Per Microsoft's own Desktop Duplication sample, that field is only
367    /// actually refreshed on a call where `LastMouseUpdateTime != 0` (the
368    /// mouse itself changed on *this* call) — on any other call its
369    /// contents aren't meaningful. Updated only when `LastMouseUpdateTime
370    /// != 0` and composited fresh onto every emitted frame (independent
371    /// of whether the desktop image itself changed that tick), so a
372    /// moving cursor over an otherwise-static screen still shows up.
373    cursor_position: POINT,
374    cursor_visible: bool,
375    /// The most recently captured composite desktop image, CPU-side —
376    /// plain, not pool-backed (never shared/pushed directly downstream;
377    /// see `run`'s own emit step, which copies out of this into a fresh
378    /// pooled frame every tick). Each unit's own crop is written directly
379    /// into its correct offset here as it's polled (see `poll_capture`),
380    /// so this is always the up-to-date composite, not something
381    /// assembled at emit time; re-copied from as-is on every tick where
382    /// nothing new arrived, which is what makes this element emit at a
383    /// constant rate rather than only on real changes. Only under
384    /// [`CaptureMode::Cpu`] — `None` under [`CaptureMode::Gpu`], which
385    /// composites straight from each unit's own `staging_texture` at
386    /// emit time instead (see [`DxgiCaptureSource::emit_frame_gpu`]).
387    staging: Option<ffmpeg::frame::Video>,
388    /// See [`DxgiCaptureOptions::fps`] — kept alongside `frame_interval`
389    /// so [`DxgiCaptureSource::time_base`] doesn't have to recover it from
390    /// a `Duration`.
391    fps: i32,
392    /// `1 / fps`.
393    frame_interval: Duration,
394    /// This element's `pts` tick counter — one per *emitted* frame (see
395    /// [`DxgiCaptureSource::time_base`]'s own docs), not per real capture.
396    frame_index: i64,
397    pad: SrcPad,
398    /// Reused across every emitted frame — see [`UnboundObjectPool`]'s
399    /// docs. Pre-sized to `width`/`height` up front, same reasoning as
400    /// `Scaler`'s own pool.
401    pool: UnboundObjectPool<ffmpeg::frame::Video>,
402}
403
404// SAFETY: every D3D11/DXGI handle here is a `windows-rs` COM interface
405// wrapper — thread-safe to hand off (refcounting is interlocked), and
406// `&mut self` on every method that touches them (mirrors `D3d12vaDecoder`/
407// `Scaler`'s own reasoning) already rules out concurrent access from
408// multiple threads.
409unsafe impl Send for DxgiCaptureSource {}
410
411impl DxgiCaptureSource {
412    /// Opens whichever output(s) [`DxgiCaptureOptions::area`] resolves to
413    /// and starts duplicating them. Returns the element alongside the
414    /// captured composite's actual `(width, height)` — what the caller
415    /// needs to build a matching downstream
416    /// [`crate::elements::Scaler`]/[`crate::elements::Pacer`], same
417    /// pattern as [`crate::elements::RtspSource::open`] returning stream
418    /// info — plus, under [`CaptureMode::Gpu`], the `ID3D11Device` this
419    /// capture was opened on (`None` under [`CaptureMode::Cpu`], where
420    /// nothing downstream needs to share it). This is always built from
421    /// whichever adapter `area` actually resolves to — see
422    /// [`CaptureMode::Gpu`]'s own docs on why callers should build every
423    /// other D3D11 element sharing this capture from the returned device
424    /// rather than a separately-created one.
425    pub fn open(
426        name: impl Into<String>,
427        options: DxgiCaptureOptions,
428    ) -> std::result::Result<(Self, u32, u32, Option<ID3D11Device>), DxgiCaptureSourceError> {
429        let name: Arc<str> = name.into().into();
430        let pp_log = element_pp_log(ElementType::DxgiCaptureSource, &name, None);
431
432        let factory: IDXGIFactory1 = unsafe { CreateDXGIFactory1() }?;
433        let gpu_mode = matches!(options.capture_mode, CaptureMode::Gpu);
434        // `CaptureMode::Gpu` has no `include_cursor` field at all (see its
435        // own docs) — nothing to extract there, so `false` unconditionally.
436        let include_cursor = match &options.capture_mode {
437            CaptureMode::Cpu { include_cursor } => *include_cursor,
438            CaptureMode::Gpu => false,
439        };
440
441        let (targets, requested) = resolve_area(&factory, &options.area)?;
442        if include_cursor && targets.len() > 1 {
443            return Err(DxgiCaptureSourceError::CursorUnsupportedForRegion);
444        }
445        let width = (requested.right - requested.left) as u32;
446        let height = (requested.bottom - requested.top) as u32;
447
448        // Always built from `area`'s own adapter (every target shares
449        // one — `resolve_area` already checked), `Cpu` and `Gpu` alike —
450        // see `CaptureMode::Gpu`'s own docs on why this element is the
451        // sole place that resolves "which adapter", rather than trusting
452        // (and validating) a caller-supplied device.
453        let adapter = &targets[0].0;
454        let mut device: Option<ID3D11Device> = None;
455        let mut context: Option<ID3D11DeviceContext> = None;
456        unsafe {
457            D3D11CreateDevice(
458                &adapter.cast::<windows::Win32::Graphics::Dxgi::IDXGIAdapter>()?,
459                D3D_DRIVER_TYPE_UNKNOWN,
460                HMODULE::default(),
461                D3D11_CREATE_DEVICE_FLAG(0),
462                None,
463                D3D11_SDK_VERSION,
464                Some(&mut device),
465                None,
466                Some(&mut context),
467            )?;
468        }
469        let device = device.expect("D3D11CreateDevice succeeded without producing a device");
470        let context = context.expect("D3D11CreateDevice succeeded without producing a context");
471        // Cloned before `device` moves into `Self` below — the only copy
472        // handed back to the caller (a COM ref-count bump, not a deep
473        // copy).
474        let returned_device = gpu_mode.then(|| device.clone());
475        let dxgi_device: IDXGIDevice = device.cast()?;
476
477        let mut units = Vec::with_capacity(targets.len());
478        for (_, output, desktop_rect) in &targets {
479            let duplication = unsafe { output.DuplicateOutput(&dxgi_device) }?;
480            let desc = unsafe { duplication.GetDesc() };
481            let unit_width = desc.ModeDesc.Width;
482            let unit_height = desc.ModeDesc.Height;
483
484            // The overlap between this output's own desktop rectangle
485            // and the requested one, expressed two ways: as a source box
486            // local to this output's own texture, and as a destination
487            // offset into the composite. `resolve_area` already
488            // guarantees a non-empty overlap for every target it
489            // returns.
490            let overlap_left = desktop_rect.left.max(requested.left);
491            let overlap_top = desktop_rect.top.max(requested.top);
492            let overlap_right = desktop_rect.right.min(requested.right);
493            let overlap_bottom = desktop_rect.bottom.min(requested.bottom);
494            let source_box = D3D11_BOX {
495                left: (overlap_left - desktop_rect.left) as u32,
496                top: (overlap_top - desktop_rect.top) as u32,
497                front: 0,
498                right: (overlap_right - desktop_rect.left) as u32,
499                bottom: (overlap_bottom - desktop_rect.top) as u32,
500                back: 1,
501            };
502            let dest_x = (overlap_left - requested.left) as u32;
503            let dest_y = (overlap_top - requested.top) as u32;
504
505            // Cpu: CPU-readable staging texture, `Map`ped every real
506            // capture (see `poll_capture`). Gpu: GPU-only — this
507            // per-output texture is only ever a `CopySubresourceRegion`
508            // source, never sampled directly, so unlike the composite
509            // `emit_frame_gpu` builds, no bind flags are needed here.
510            let staging_desc = D3D11_TEXTURE2D_DESC {
511                Width: unit_width,
512                Height: unit_height,
513                MipLevels: 1,
514                ArraySize: 1,
515                Format: DXGI_FORMAT_B8G8R8A8_UNORM,
516                SampleDesc: DXGI_SAMPLE_DESC {
517                    Count: 1,
518                    Quality: 0,
519                },
520                Usage: if gpu_mode {
521                    D3D11_USAGE_DEFAULT
522                } else {
523                    D3D11_USAGE_STAGING
524                },
525                BindFlags: D3D11_BIND_FLAG(0).0 as u32,
526                CPUAccessFlags: if gpu_mode {
527                    0
528                } else {
529                    D3D11_CPU_ACCESS_READ.0 as u32
530                },
531                MiscFlags: 0,
532            };
533            let mut staging_texture: Option<ID3D11Texture2D> = None;
534            unsafe { device.CreateTexture2D(&staging_desc, None, Some(&mut staging_texture)) }?;
535            let staging_texture =
536                staging_texture.expect("CreateTexture2D succeeded without producing a texture");
537
538            units.push(CaptureUnit {
539                duplication,
540                staging_texture,
541                source_box,
542                dest_x,
543                dest_y,
544                has_captured: false,
545            });
546        }
547
548        let pad = SrcPad::new(format!("{name}_src"));
549        // Gpu: only the small CPU-side `AVFrame` wrapper is ever pooled
550        // (`ffmpeg::frame::Video::empty` — same as `D3d11Upload`'s own
551        // pool); the GPU texture itself is a fresh allocation every
552        // `emit_frame` call (see that method's own docs on why). Cpu:
553        // pre-sized real `Pixel::BGRA` CPU buffers, as before.
554        let pool = if gpu_mode {
555            UnboundObjectPool::new(0, ffmpeg::frame::Video::empty, |_| {})
556        } else {
557            UnboundObjectPool::new(
558                0,
559                move || ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, width, height),
560                |_| {},
561            )
562        };
563        let staging = (!gpu_mode)
564            .then(|| ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, width, height));
565
566        let fps = options.fps.max(1); // a `0` fps is nonsensical; treat it as 1 rather than dividing by zero
567        pp_info!(
568            pp_log: &pp_log,
569            "opened: {}x{} composite from {} output(s), include_cursor={}, fps={}, gpu_mode={}",
570            width,
571            height,
572            units.len(),
573            include_cursor,
574            fps,
575            gpu_mode
576        );
577
578        Ok((
579            Self {
580                name,
581                pp_log,
582                device,
583                context,
584                units,
585                width,
586                height,
587                gpu_mode,
588                include_cursor,
589                cursor_shape: None,
590                cursor_position: POINT::default(),
591                cursor_visible: false,
592                staging,
593                fps: fps as i32,
594                frame_interval: Duration::from_secs_f64(1.0 / fps as f64),
595                frame_index: 0,
596                pad,
597                pool,
598            },
599            width,
600            height,
601            returned_device,
602        ))
603    }
604
605    /// The unit each emitted frame's `pts` is expressed in — what you
606    /// need to construct a matching [`crate::elements::Pacer`]. `1 /
607    /// fps`, same convention as [`crate::elements::TestVideoSource::time_base`].
608    pub fn time_base(&self) -> ffmpeg::Rational {
609        ffmpeg::Rational::new(1, self.fps)
610    }
611
612    /// Whether every contributing output has captured at least one real
613    /// image yet — see [`CaptureUnit::has_captured`]'s own docs.
614    fn all_captured(&self) -> bool {
615        self.units.iter().all(|unit| unit.has_captured)
616    }
617
618    /// Refreshes `self.cursor_shape` from `unit_index`'s duplication
619    /// interface's current pointer shape buffer. Only called when
620    /// `DXGI_OUTDUPL_FRAME_INFO::PointerShapeBufferSize > 0` — i.e. the
621    /// shape actually changed since the last call. Only ever called with
622    /// `unit_index == 0`: `open` rejects `include_cursor` whenever more
623    /// than one unit exists (see [`CaptureArea::Region`]'s own docs).
624    fn refresh_cursor_shape(
625        &mut self,
626        unit_index: usize,
627        buffer_size: usize,
628    ) -> std::result::Result<(), windows::core::Error> {
629        let mut buffer = vec![0u8; buffer_size];
630        let mut required = 0u32;
631        let mut info = DXGI_OUTDUPL_POINTER_SHAPE_INFO::default();
632        unsafe {
633            self.units[unit_index].duplication.GetFramePointerShape(
634                buffer.len() as u32,
635                buffer.as_mut_ptr() as *mut c_void,
636                &mut required,
637                &mut info,
638            )?;
639        }
640        buffer.truncate(required as usize);
641        self.cursor_shape = Some(CursorShape {
642            kind: info.Type,
643            width: info.Width,
644            height: info.Height,
645            pitch: info.Pitch,
646            data: buffer,
647        });
648        Ok(())
649    }
650
651    /// Tries once to capture a new image from every contributing output,
652    /// within `timeout_ms` total — Desktop Duplication has no "wait on
653    /// any of these" primitive, so `timeout_ms` is split evenly across
654    /// `self.units` (more outputs means a longer total `poll_capture`
655    /// call for the same per-unit responsiveness, still bounded overall
656    /// by `POLL_GRANULARITY` same as the single-output case always was).
657    /// Under [`CaptureMode::Cpu`], each unit that captured a new image
658    /// writes its own crop directly into `self.staging` at its own
659    /// composite offset. Always refreshes the cached cursor
660    /// position/shape (see their own docs) regardless, since the mouse
661    /// can move independently of the desktop image. A
662    /// `DXGI_ERROR_WAIT_TIMEOUT` on any one unit (nothing changed within
663    /// its share of `timeout_ms`) is not an error — that unit is simply
664    /// unchanged this call.
665    fn poll_capture(&mut self, timeout_ms: u32) -> std::result::Result<(), DxgiCaptureSourceError> {
666        let per_unit_timeout = timeout_ms / self.units.len() as u32;
667        for index in 0..self.units.len() {
668            let mut info = DXGI_OUTDUPL_FRAME_INFO::default();
669            let mut resource: Option<IDXGIResource> = None;
670            let acquire = unsafe {
671                self.units[index].duplication.AcquireNextFrame(
672                    per_unit_timeout,
673                    &mut info,
674                    &mut resource,
675                )
676            };
677            let resource = match acquire {
678                Ok(()) => resource.expect("AcquireNextFrame succeeded without a resource"),
679                Err(error) if error.code() == DXGI_ERROR_WAIT_TIMEOUT => continue,
680                Err(error) if error.code() == DXGI_ERROR_ACCESS_LOST => {
681                    return Err(DxgiCaptureSourceError::AccessLost);
682                }
683                Err(error) => return Err(error.into()),
684            };
685
686            if self.include_cursor {
687                if info.PointerShapeBufferSize > 0 {
688                    self.refresh_cursor_shape(index, info.PointerShapeBufferSize as usize)?;
689                }
690                // See `cursor_position`/`cursor_visible`'s own docs: only
691                // trust `info.PointerPosition` on the call where the
692                // mouse itself actually changed.
693                if info.LastMouseUpdateTime != 0 {
694                    self.cursor_position = info.PointerPosition.Position;
695                    self.cursor_visible = info.PointerPosition.Visible.as_bool();
696                }
697            }
698
699            // `AcquireNextFrame` succeeds not just when the desktop image
700            // itself changed, but also on a *cursor-only* update (the
701            // pointer moved/blinked with the picture underneath it
702            // untouched) — `AccumulatedFrames == 0` is how DXGI signals
703            // that case (see Microsoft's own Desktop Duplication sample).
704            // The cursor position was already refreshed above
705            // regardless; there's just no new *image* to copy out, so
706            // release and move to the next unit.
707            if info.AccumulatedFrames == 0 {
708                unsafe { self.units[index].duplication.ReleaseFrame() }?;
709                continue;
710            }
711
712            // Every fallible step here (the two `cast`s, the copy itself)
713            // must still release DXGI's own frame lock on the way out —
714            // an early `?` before `ReleaseFrame()` would leave this unit
715            // unable to `AcquireNextFrame` again until it's torn down
716            // entirely, so the copy's own result is captured instead of
717            // propagated directly.
718            let copy_result: std::result::Result<(), DxgiCaptureSourceError> = (|| {
719                let texture: ID3D11Texture2D = resource.cast()?;
720                unsafe {
721                    self.context.CopyResource(
722                        &self.units[index].staging_texture.cast::<ID3D11Resource>()?,
723                        &texture.cast::<ID3D11Resource>()?,
724                    );
725                }
726                Ok(())
727            })();
728
729            // Release DXGI's own frame as soon as we've copied it out,
730            // rather than holding it while we map/read (Cpu mode) the
731            // (independent) staging copy below — and unconditionally,
732            // even if the copy above failed.
733            let release_result = unsafe { self.units[index].duplication.ReleaseFrame() };
734            copy_result?;
735            release_result?;
736
737            if self.gpu_mode {
738                // No `Map`/CPU copy at all — `staging_texture` itself
739                // *is* this unit's latest capture; `emit_frame_gpu` reads
740                // straight from it. See `CaptureMode::Gpu`'s own docs.
741                self.units[index].has_captured = true;
742                continue;
743            }
744
745            // Cpu mode: Map this unit's own staging texture and copy
746            // just its `source_box` crop into the shared composite
747            // buffer at `dest_x`/`dest_y` — no separate composite GPU
748            // texture needed, the crop lands directly in CPU memory at
749            // its final position.
750            let mut mapped = Default::default();
751            unsafe {
752                self.context.Map(
753                    &self.units[index].staging_texture.cast::<ID3D11Resource>()?,
754                    0,
755                    D3D11_MAP_READ,
756                    0,
757                    Some(&mut mapped),
758                )?;
759            }
760            {
761                let unit = &self.units[index];
762                let box_ = unit.source_box;
763                let crop_width = (box_.right - box_.left) as usize;
764                let crop_height = (box_.bottom - box_.top) as usize;
765                let row_bytes = crop_width * 4;
766                let staging = self
767                    .staging
768                    .as_mut()
769                    .expect("CaptureMode::Cpu always has a staging buffer");
770                let dst_stride = staging.stride(0);
771                let dst = staging.data_mut(0);
772                for row in 0..crop_height {
773                    let src_row = box_.top as usize + row;
774                    let src = unsafe {
775                        std::slice::from_raw_parts(
776                            (mapped.pData as *const u8)
777                                .add(src_row * mapped.RowPitch as usize + box_.left as usize * 4),
778                            row_bytes,
779                        )
780                    };
781                    let dst_row = unit.dest_y as usize + row;
782                    let dst_col = unit.dest_x as usize * 4;
783                    dst[dst_row * dst_stride + dst_col..dst_row * dst_stride + dst_col + row_bytes]
784                        .copy_from_slice(src);
785                }
786            }
787            unsafe {
788                self.context.Unmap(
789                    &self.units[index].staging_texture.cast::<ID3D11Resource>()?,
790                    0,
791                );
792            }
793            self.units[index].has_captured = true;
794        }
795        Ok(())
796    }
797
798    /// Builds the next frame to push — the unit of work `run` does once
799    /// per emission tick, real change or repeat — and stamps the next
800    /// `pts` and this source's fixed color description. Dispatches to
801    /// [`DxgiCaptureSource::emit_frame_cpu`] or
802    /// [`DxgiCaptureSource::emit_frame_gpu`] depending on `self.gpu_mode`.
803    ///
804    /// Both modes produce BGRA, so both get the same description, and it
805    /// matches what [`crate::elements::D3d11VideoCompositor`] already stamps
806    /// on its own BGRA output: `Space::RGB` because these are RGB samples
807    /// with no luma/chroma matrix applied, and `Range::JPEG` because desktop
808    /// pixels are full-range 0-255, not studio-swing.
809    ///
810    /// Downstream consumers read this rather than guessing:
811    /// `D3d11VideoCompositor` uses it to pick its NV12 conversion matrix,
812    /// and [`crate::elements::D3d11NvencEncoder`] forwards it. Note it does
813    /// **not** change what a BGRA-input NVENC recording is tagged with —
814    /// NVENC converts RGB to YUV inside its own encode block with a fixed
815    /// matrix and tags the bitstream to match what it actually did, which
816    /// is why that path stays self-consistent either way.
817    fn emit_frame(
818        &mut self,
819    ) -> std::result::Result<
820        crate::pool::UnboundObjectPoolRef<ffmpeg::frame::Video>,
821        DxgiCaptureSourceError,
822    > {
823        let mut frame = if self.gpu_mode {
824            self.emit_frame_gpu()?
825        } else {
826            self.emit_frame_cpu()
827        };
828        frame.set_pts(Some(self.frame_index));
829        frame.set_color_space(ffmpeg::color::Space::RGB);
830        frame.set_color_range(ffmpeg::color::Range::JPEG);
831        self.frame_index += 1;
832        Ok(frame)
833    }
834
835    /// Copies `self.staging` (the latest captured composite image,
836    /// however stale — already assembled from every unit's own crop by
837    /// `poll_capture`) into a fresh pooled CPU frame, compositing the
838    /// cursor onto that copy if enabled. Copying instead of sharing
839    /// `self.staging` directly is what lets each emitted frame carry its
840    /// own distinct, correctly-incrementing `pts` (stamped by the
841    /// caller, `emit_frame`) even when several emissions in a row show
842    /// the same content — an `Arc`-shared frame can't have its `pts`
843    /// safely rewritten in place once downstream might already hold a
844    /// clone of it.
845    fn emit_frame_cpu(&mut self) -> crate::pool::UnboundObjectPoolRef<ffmpeg::frame::Video> {
846        let mut frame = self.pool.get();
847        let staging = self
848            .staging
849            .as_ref()
850            .expect("CaptureMode::Cpu always has a staging buffer");
851        {
852            let dst_stride = frame.stride(0);
853            let src_stride = staging.stride(0);
854            let row_bytes = self.width as usize * 4;
855            let src = staging.data(0);
856            let dst = frame.data_mut(0);
857            for row in 0..self.height as usize {
858                dst[row * dst_stride..row * dst_stride + row_bytes]
859                    .copy_from_slice(&src[row * src_stride..row * src_stride + row_bytes]);
860            }
861            if self.include_cursor
862                && self.cursor_visible
863                && let Some(shape) = &self.cursor_shape
864            {
865                composite_cursor(
866                    dst,
867                    dst_stride,
868                    self.width,
869                    self.height,
870                    self.cursor_position.x,
871                    self.cursor_position.y,
872                    shape,
873                );
874            }
875        }
876        frame
877    }
878
879    /// `CaptureMode::Gpu`'s equivalent of [`DxgiCaptureSource::emit_frame_cpu`]:
880    /// builds a fresh composite `ID3D11Texture2D` (`self.width` x
881    /// `self.height`) and `CopySubresourceRegion`s every unit's own
882    /// `source_box` crop into it at that unit's `dest_x`/`dest_y` — one
883    /// GPU-side copy per contributing output, same reasoning
884    /// `D3d11Upload::upload` documents for allocating fresh every call
885    /// rather than reusing one texture: each unit's own `staging_texture`
886    /// gets overwritten by its next real capture, so a frame already
887    /// pushed downstream needs its own, independently stable copy of
888    /// what it was capturing at push time. Wraps the fresh texture as a
889    /// `Pixel::D3D11` frame via [`wrap_d3d11_texture`], reusing the
890    /// pooled `AVFrame` wrapper (the pool built by `open` for
891    /// `CaptureMode::Gpu` only holds these small wrappers, not GPU
892    /// memory — see that pool's own construction site).
893    fn emit_frame_gpu(
894        &mut self,
895    ) -> std::result::Result<
896        crate::pool::UnboundObjectPoolRef<ffmpeg::frame::Video>,
897        DxgiCaptureSourceError,
898    > {
899        let desc = D3D11_TEXTURE2D_DESC {
900            Width: self.width,
901            Height: self.height,
902            MipLevels: 1,
903            ArraySize: 1,
904            Format: DXGI_FORMAT_B8G8R8A8_UNORM,
905            SampleDesc: DXGI_SAMPLE_DESC {
906                Count: 1,
907                Quality: 0,
908            },
909            Usage: D3D11_USAGE_DEFAULT,
910            BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
911            CPUAccessFlags: 0,
912            MiscFlags: 0,
913        };
914        let mut texture: Option<ID3D11Texture2D> = None;
915        unsafe {
916            self.device
917                .CreateTexture2D(&desc, None, Some(&mut texture))?;
918        }
919        let texture = texture.expect("CreateTexture2D succeeded without producing a texture");
920        let dst_resource: ID3D11Resource = texture.cast()?;
921        for unit in &self.units {
922            let src_resource: ID3D11Resource = unit.staging_texture.cast()?;
923            let box_ = unit.source_box;
924            unsafe {
925                self.context.CopySubresourceRegion(
926                    &dst_resource,
927                    0,
928                    unit.dest_x,
929                    unit.dest_y,
930                    0,
931                    &src_resource,
932                    0,
933                    Some(&box_ as *const D3D11_BOX),
934                );
935            }
936        }
937
938        let mut frame = self.pool.get();
939        // Overwrites the pooled slot's previous contents in place —
940        // `ffmpeg::frame::Video`'s own `Drop` runs on whatever was there
941        // before, releasing that frame's GPU texture right here (same
942        // pattern `D3d11Upload::consume` documents).
943        *frame = wrap_d3d11_texture(texture, self.width, self.height);
944        Ok(frame)
945    }
946}
947
948impl Element for DxgiCaptureSource {
949    fn name(&self) -> Arc<str> {
950        self.name.clone()
951    }
952
953    fn element_type(&self) -> ElementType {
954        ElementType::DxgiCaptureSource
955    }
956
957    fn pp_log(&self) -> &PpLog {
958        &self.pp_log
959    }
960
961    fn pp_log_mut(&mut self) -> &mut PpLog {
962        &mut self.pp_log
963    }
964}
965
966impl Source for DxgiCaptureSource {
967    fn src_pads(&mut self) -> &mut [SrcPad] {
968        std::slice::from_mut(&mut self.pad)
969    }
970}
971
972impl SourceElement for DxgiCaptureSource {
973    fn run(&mut self, control: &ControlReceiver, bus: &Bus) -> Result<()> {
974        pp_info!(self, "started");
975        let mut schedule = PeriodicSchedule::new(self.frame_interval, Instant::now());
976        loop {
977            let outcome = drain_control(control, self, bus)?;
978            if outcome.stopped {
979                pp_info!(self, "stopped");
980                return Ok(());
981            }
982            if outcome.paused_for > Duration::ZERO {
983                schedule.resume_after_pause(outcome.paused_for, Instant::now());
984            }
985
986            let poll_timeout = schedule.remaining(Instant::now()).min(POLL_GRANULARITY);
987            if let Err(error) = self.poll_capture(poll_timeout.as_millis() as u32) {
988                pp_error!(self, "capture failed: {error}");
989                return Err(error.into());
990            }
991
992            if !schedule.is_due(Instant::now()) {
993                continue;
994            }
995
996            if !self.all_captured() {
997                // Still advance even though there's nothing to emit this
998                // tick — otherwise `next_due` sits in the past and the
999                // next iteration's `poll_timeout` above is zero, busy-looping
1000                // instead of waiting for the next tick.
1001                schedule.advance_after_tick(Instant::now());
1002                continue; // nothing real captured yet — nothing to emit
1003            }
1004            let frame = match self.emit_frame() {
1005                Ok(frame) => frame,
1006                Err(error) => {
1007                    pp_error!(self, "emit_frame failed: {error}");
1008                    return Err(error.into());
1009                }
1010            };
1011            if let Err(error) = self.pad.push(MediaBuffer::Video(Arc::new(frame))) {
1012                bus.post(
1013                    &self.pp_log,
1014                    BusEvent::Error {
1015                        element_type: ElementType::DxgiCaptureSource,
1016                        name: self.name.clone(),
1017                        error,
1018                    },
1019                );
1020            }
1021            // Advance only now that this tick's own work (emit + push,
1022            // which a slow downstream/GPU readback can stretch
1023            // arbitrarily) is done — see `TestVideoSource::run`'s
1024            // identical correction for why the placement matters.
1025            schedule.advance_after_tick(Instant::now());
1026        }
1027    }
1028
1029    fn seek(&mut self, _target: Duration) -> Result<Duration> {
1030        Err(DxgiCaptureSourceError::SeekUnsupported.into())
1031    }
1032}
1033
1034fn pick_output(
1035    factory: &IDXGIFactory1,
1036    output_index: u32,
1037) -> std::result::Result<(IDXGIAdapter1, IDXGIOutput1), DxgiCaptureSourceError> {
1038    let mut remaining = output_index;
1039    let mut adapter_index = 0u32;
1040    loop {
1041        let adapter = match unsafe { factory.EnumAdapters1(adapter_index) } {
1042            Ok(adapter) => adapter,
1043            Err(_) => return Err(DxgiCaptureSourceError::NoSuchOutput(output_index)),
1044        };
1045        let mut output_i = 0u32;
1046        loop {
1047            let output = match unsafe { adapter.EnumOutputs(output_i) } {
1048                Ok(output) => output,
1049                Err(_) => break,
1050            };
1051            if remaining == 0 {
1052                let output1: IDXGIOutput1 = output.cast()?;
1053                return Ok((adapter, output1));
1054            }
1055            remaining -= 1;
1056            output_i += 1;
1057        }
1058        adapter_index += 1;
1059    }
1060}
1061
1062/// One output resolved by [`resolve_area`]: its own adapter, the output
1063/// itself, and its absolute-desktop `DesktopCoordinates`.
1064type ResolvedOutput = (IDXGIAdapter1, IDXGIOutput1, RECT);
1065
1066/// Resolves `area` into the concrete output(s) it captures from, plus the
1067/// absolute-desktop rectangle actually requested. [`CaptureArea::Output`]
1068/// always resolves to exactly one target (that output's own
1069/// `DesktopCoordinates` doubles as the requested rectangle — the whole
1070/// monitor). [`CaptureArea::Region`] resolves to every output whose own
1071/// desktop rectangle intersects the requested one —
1072/// [`DxgiCaptureSourceError::RegionOutsideDesktop`] if none do — and fails
1073/// with [`DxgiCaptureSourceError::RegionSpansMultipleAdapters`] if those
1074/// outputs aren't all on the same adapter, checked here before
1075/// [`DxgiCaptureSource::open`] opens any duplication.
1076fn resolve_area(
1077    factory: &IDXGIFactory1,
1078    area: &CaptureArea,
1079) -> std::result::Result<(Vec<ResolvedOutput>, RECT), DxgiCaptureSourceError> {
1080    match *area {
1081        CaptureArea::Output { output_index } => {
1082            let (adapter, output) = pick_output(factory, output_index)?;
1083            let desktop_rect = unsafe {
1084                output
1085                    .cast::<windows::Win32::Graphics::Dxgi::IDXGIOutput>()?
1086                    .GetDesc()
1087            }?
1088            .DesktopCoordinates;
1089            Ok((vec![(adapter, output, desktop_rect)], desktop_rect))
1090        }
1091        CaptureArea::Region(rect) => {
1092            let requested = RECT {
1093                left: rect.x,
1094                top: rect.y,
1095                right: rect.x + rect.width as i32,
1096                bottom: rect.y + rect.height as i32,
1097            };
1098            let mut targets = Vec::new();
1099            let mut adapter_index = 0u32;
1100            loop {
1101                let adapter = match unsafe { factory.EnumAdapters1(adapter_index) } {
1102                    Ok(adapter) => adapter,
1103                    Err(_) => break,
1104                };
1105                let mut output_i = 0u32;
1106                loop {
1107                    let output = match unsafe { adapter.EnumOutputs(output_i) } {
1108                        Ok(output) => output,
1109                        Err(_) => break,
1110                    };
1111                    let output1: IDXGIOutput1 = output.cast()?;
1112                    let desktop_rect = unsafe {
1113                        output1
1114                            .cast::<windows::Win32::Graphics::Dxgi::IDXGIOutput>()?
1115                            .GetDesc()
1116                    }?
1117                    .DesktopCoordinates;
1118                    let intersects = desktop_rect.left < requested.right
1119                        && desktop_rect.right > requested.left
1120                        && desktop_rect.top < requested.bottom
1121                        && desktop_rect.bottom > requested.top;
1122                    if intersects {
1123                        targets.push((adapter.clone(), output1, desktop_rect));
1124                    }
1125                    output_i += 1;
1126                }
1127                adapter_index += 1;
1128            }
1129            if targets.is_empty() {
1130                return Err(DxgiCaptureSourceError::RegionOutsideDesktop(rect));
1131            }
1132            let first_luid = unsafe {
1133                targets[0]
1134                    .0
1135                    .cast::<windows::Win32::Graphics::Dxgi::IDXGIAdapter>()?
1136                    .GetDesc()
1137            }?
1138            .AdapterLuid;
1139            for (adapter, _, _) in &targets[1..] {
1140                let luid = unsafe {
1141                    adapter
1142                        .cast::<windows::Win32::Graphics::Dxgi::IDXGIAdapter>()?
1143                        .GetDesc()
1144                }?
1145                .AdapterLuid;
1146                if (luid.LowPart, luid.HighPart) != (first_luid.LowPart, first_luid.HighPart) {
1147                    return Err(DxgiCaptureSourceError::RegionSpansMultipleAdapters);
1148                }
1149            }
1150            Ok((targets, requested))
1151        }
1152    }
1153}
1154
1155/// Blends [`CursorShape`] onto `dst` (a `Pixel::BGRA` plane, `dst_stride`
1156/// bytes per row, `dst_width`x`dst_height` pixels) at `(pos_x, pos_y)`,
1157/// clipped to `dst`'s bounds — the position can legitimately fall partly
1158/// outside this output's captured region on a multi-monitor setup.
1159/// Implements the three DXGI pointer shape kinds per MSDN's
1160/// `DXGI_OUTDUPL_POINTER_SHAPE_TYPE` docs. A pure function over byte
1161/// buffers (no D3D calls) so it's unit-testable without a live capture.
1162fn composite_cursor(
1163    dst: &mut [u8],
1164    dst_stride: usize,
1165    dst_width: u32,
1166    dst_height: u32,
1167    pos_x: i32,
1168    pos_y: i32,
1169    shape: &CursorShape,
1170) {
1171    if shape.kind == DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME.0 as u32 {
1172        let mask_height = shape.height / 2;
1173        for row in 0..mask_height {
1174            for col in 0..shape.width {
1175                let byte_col = (col / 8) as usize;
1176                let bit = 7 - (col % 8);
1177                let and_byte = shape.data[row as usize * shape.pitch as usize + byte_col];
1178                let xor_byte =
1179                    shape.data[(mask_height + row) as usize * shape.pitch as usize + byte_col];
1180                let and_bit = (and_byte >> bit) & 1;
1181                let xor_bit = (xor_byte >> bit) & 1;
1182                let (x, y) = (pos_x + col as i32, pos_y + row as i32);
1183                if x < 0 || y < 0 || x as u32 >= dst_width || y as u32 >= dst_height {
1184                    continue;
1185                }
1186                let offset = y as usize * dst_stride + x as usize * 4;
1187                match (and_bit, xor_bit) {
1188                    (0, 0) => {
1189                        dst[offset] = 0;
1190                        dst[offset + 1] = 0;
1191                        dst[offset + 2] = 0;
1192                        dst[offset + 3] = 255;
1193                    }
1194                    (0, 1) => {
1195                        dst[offset] = 255;
1196                        dst[offset + 1] = 255;
1197                        dst[offset + 2] = 255;
1198                        dst[offset + 3] = 255;
1199                    }
1200                    (1, 0) => {}
1201                    _ => {
1202                        dst[offset] ^= 0xFF;
1203                        dst[offset + 1] ^= 0xFF;
1204                        dst[offset + 2] ^= 0xFF;
1205                    }
1206                }
1207            }
1208        }
1209    } else if shape.kind == DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR.0 as u32 {
1210        for row in 0..shape.height {
1211            for col in 0..shape.width {
1212                let idx = row as usize * shape.pitch as usize + col as usize * 4;
1213                let (b, g, r, a) = (
1214                    shape.data[idx],
1215                    shape.data[idx + 1],
1216                    shape.data[idx + 2],
1217                    shape.data[idx + 3],
1218                );
1219                let (x, y) = (pos_x + col as i32, pos_y + row as i32);
1220                if x < 0 || y < 0 || x as u32 >= dst_width || y as u32 >= dst_height {
1221                    continue;
1222                }
1223                let offset = y as usize * dst_stride + x as usize * 4;
1224                let inv = 255 - a as u32;
1225                dst[offset] = ((b as u32 * a as u32 + dst[offset] as u32 * inv) / 255) as u8;
1226                dst[offset + 1] =
1227                    ((g as u32 * a as u32 + dst[offset + 1] as u32 * inv) / 255) as u8;
1228                dst[offset + 2] =
1229                    ((r as u32 * a as u32 + dst[offset + 2] as u32 * inv) / 255) as u8;
1230                dst[offset + 3] = 255;
1231            }
1232        }
1233    } else if shape.kind == DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR.0 as u32 {
1234        for row in 0..shape.height {
1235            for col in 0..shape.width {
1236                let idx = row as usize * shape.pitch as usize + col as usize * 4;
1237                let (b, g, r, a) = (
1238                    shape.data[idx],
1239                    shape.data[idx + 1],
1240                    shape.data[idx + 2],
1241                    shape.data[idx + 3],
1242                );
1243                let (x, y) = (pos_x + col as i32, pos_y + row as i32);
1244                if x < 0 || y < 0 || x as u32 >= dst_width || y as u32 >= dst_height {
1245                    continue;
1246                }
1247                let offset = y as usize * dst_stride + x as usize * 4;
1248                if a == 0xFF {
1249                    dst[offset] ^= b;
1250                    dst[offset + 1] ^= g;
1251                    dst[offset + 2] ^= r;
1252                } else {
1253                    dst[offset] = b;
1254                    dst[offset + 1] = g;
1255                    dst[offset + 2] = r;
1256                    dst[offset + 3] = 255;
1257                }
1258            }
1259        }
1260    }
1261}
1262
1263#[cfg(test)]
1264mod tests {
1265    use super::*;
1266
1267    fn blank_frame(width: u32, height: u32) -> (Vec<u8>, usize) {
1268        let stride = width as usize * 4;
1269        (vec![0u8; stride * height as usize], stride)
1270    }
1271
1272    #[test]
1273    fn monochrome_cursor_draws_black_white_and_leaves_transparent_alone() {
1274        let (mut dst, stride) = blank_frame(4, 4);
1275        // 2x2 mask: AND=0/XOR=0 (black), AND=0/XOR=1 (white),
1276        // AND=1/XOR=0 (unchanged), AND=1/XOR=1 (invert).
1277        // AND row: bits 0,0,1,1 -> 0b00110000 in the top nibble (MSB first)
1278        // XOR row: bits 0,1,0,1 -> 0b01010000
1279        let and_row = 0b0011_0000u8;
1280        let xor_row = 0b0101_0000u8;
1281        dst[stride + 2 * 4] = 200; // pre-existing pixel at (2,1) to check invert
1282        dst[stride + 2 * 4 + 1] = 100;
1283        dst[stride + 2 * 4 + 2] = 50;
1284        let shape = CursorShape {
1285            kind: DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME.0 as u32,
1286            width: 4,
1287            height: 4, // 2 rows AND + 2 rows XOR
1288            pitch: 1,
1289            data: vec![and_row, and_row, xor_row, xor_row],
1290        };
1291        composite_cursor(&mut dst, stride, 4, 4, 0, 0, &shape);
1292
1293        // (0,0): and=0,xor=0 -> black
1294        assert_eq!(&dst[0..4], &[0, 0, 0, 255]);
1295        // (1,0): and=0,xor=1 -> white
1296        assert_eq!(&dst[4..8], &[255, 255, 255, 255]);
1297        // (2,1): and=1,xor=0 -> unchanged (pre-existing pixel)
1298        let off = stride + 2 * 4;
1299        assert_eq!(&dst[off..off + 3], &[200, 100, 50]);
1300        // (3,1): and=1,xor=1 -> inverted from 0 -> 255
1301        let off = stride + 3 * 4;
1302        assert_eq!(&dst[off..off + 3], &[255, 255, 255]);
1303    }
1304
1305    #[test]
1306    fn color_cursor_alpha_blends_over_destination() {
1307        let (mut dst, stride) = blank_frame(2, 1);
1308        dst[0..4].copy_from_slice(&[10, 20, 30, 255]); // dst pixel (0,0)
1309        let shape = CursorShape {
1310            kind: DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR.0 as u32,
1311            width: 1,
1312            height: 1,
1313            pitch: 4,
1314            data: vec![200, 150, 100, 255], // fully opaque src -> fully replaces
1315        };
1316        composite_cursor(&mut dst, stride, 2, 1, 0, 0, &shape);
1317        assert_eq!(&dst[0..4], &[200, 150, 100, 255]);
1318    }
1319
1320    #[test]
1321    fn masked_color_cursor_xors_when_alpha_is_full_and_replaces_otherwise() {
1322        let (mut dst, stride) = blank_frame(2, 1);
1323        dst[0..4].copy_from_slice(&[0b1010_1010, 0, 0, 255]);
1324        dst[4..8].copy_from_slice(&[1, 2, 3, 255]);
1325        let shape = CursorShape {
1326            kind: DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR.0 as u32,
1327            width: 2,
1328            height: 1,
1329            pitch: 8,
1330            data: vec![
1331                0b0101_0101,
1332                0,
1333                0,
1334                0xFF, // xor at (0,0)
1335                77,
1336                88,
1337                99,
1338                0x00, // replace at (1,0)
1339            ],
1340        };
1341        composite_cursor(&mut dst, stride, 2, 1, 0, 0, &shape);
1342        assert_eq!(dst[0], 0b1010_1010 ^ 0b0101_0101);
1343        assert_eq!(&dst[4..8], &[77, 88, 99, 255]);
1344    }
1345
1346    /// Every emitted frame has to describe its own color, in both capture
1347    /// modes: `D3d11VideoCompositor` reads exactly these two fields to pick
1348    /// an NV12 conversion matrix, and leaving them unset makes it fall back
1349    /// to a guess instead of using what this source actually produces.
1350    ///
1351    /// Skips when the machine has no desktop to duplicate (a headless or
1352    /// session-0 runner), since that is a real environment rather than a
1353    /// failure.
1354    #[test]
1355    fn emitted_frames_describe_full_range_rgb_in_both_modes() {
1356        use crate::{buffer::MediaBuffer, elements::AppSink, pipeline::Pipeline};
1357        use std::sync::{Arc, Mutex};
1358
1359        for capture_mode in [
1360            CaptureMode::Cpu {
1361                include_cursor: false,
1362            },
1363            CaptureMode::Gpu,
1364        ] {
1365            let options = DxgiCaptureOptions {
1366                fps: 30,
1367                capture_mode: capture_mode.clone(),
1368                ..DxgiCaptureOptions::default()
1369            };
1370            let Ok((source, _width, _height, _device)) =
1371                DxgiCaptureSource::open("test-capture", options)
1372            else {
1373                eprintln!("skipping {capture_mode:?}: no duplicable desktop on this machine");
1374                continue;
1375            };
1376
1377            let seen: Arc<Mutex<Option<(ffmpeg::color::Space, ffmpeg::color::Range)>>> =
1378                Arc::new(Mutex::new(None));
1379            let recorded = seen.clone();
1380            let sink = AppSink::new("test-capture-sink", move |buf| {
1381                if let MediaBuffer::Video(frame) = buf {
1382                    let mut slot = recorded
1383                        .lock()
1384                        .unwrap_or_else(|poisoned| poisoned.into_inner());
1385                    slot.get_or_insert((frame.color_space(), frame.color_range()));
1386                }
1387                Ok(())
1388            });
1389
1390            let pipeline = Pipeline::new("capture-color", source, |source, ctx| {
1391                let branch = ctx.branch().to(Box::new(sink))?;
1392                ctx.attach(source, 0, branch)?;
1393                Ok(())
1394            })
1395            .expect("wiring a capture source to an AppSink should succeed");
1396            pipeline.run();
1397            std::thread::sleep(std::time::Duration::from_millis(300));
1398            pipeline.stop();
1399
1400            let observed = seen
1401                .lock()
1402                .unwrap_or_else(|poisoned| poisoned.into_inner())
1403                .take();
1404            let Some((space, range)) = observed else {
1405                eprintln!("skipping {capture_mode:?}: capture produced no frame in time");
1406                continue;
1407            };
1408            assert_eq!(
1409                space,
1410                ffmpeg::color::Space::RGB,
1411                "{capture_mode:?} must describe its BGRA samples as RGB, not leave them unspecified"
1412            );
1413            assert_eq!(
1414                range,
1415                ffmpeg::color::Range::JPEG,
1416                "{capture_mode:?} must describe desktop pixels as full range"
1417            );
1418        }
1419    }
1420}